iT邦幫忙

2026 iThome 鐵人賽

DAY 22
0
AI Security

你該防的不是駭客,是你自己的 AI:在本機驗證你的防線系列 第 22

Day 22|CI 紅燈代表什麼?先分清失敗、跳過與已知缺口

  • 分享至 

  • xImage
  •  

昨天那三條回歸測試躺在專案裡,要人想起來才會跑。今天把它們接進 CI。

我以為第一件事是分級:哪一條沒過就該擋住你,哪一條只要提醒一聲。昨天結尾我也是這樣寫的。結果我得先處理別的。

先講為什麼這件事在這個系列裡特別急。這 21 支檢查驗的是同一批東西:那組每次改完 prompt 都要重打一遍的攻擊集、模型講的話變成動作之前那道閘、輸入側的四道閘、還有昨天那三條回歸。這些防線會在你一行程式碼都沒改的情況下失效:你改了 prompt 裡的一個字、供應商換了模型版本、或者同一個輸入這次擋下來下次沒有。一般的程式也會因為相依套件升版或外部服務改行為而變,差別在模型的輸出本來就不是決定性的,你很難靠「我記得要跑一下」接住它。

先量,再決定要不要分級

接 CI 之前,我想知道這 21 支跑完要多久。序列跑一次:

合計 568 秒(9.5 分鐘)

九分半。這個數字如果是真的,每次推上去都得等它跑完,確實會讓人開始按跳過。

但拆開來看,一支就佔了 500 秒,而且它是紅的:

05-innerhtml-fake-green   500s  rc=1

點進去看,24 個檢查沒有一個拿到結果,卡的都是同一句話:

[!] Chrome 超過 30 秒沒有結束,已強制中止。它自己說:
    Trying to load the allocator multiple times. This is *not* supported.

根因在它找瀏覽器的第一個候選:

"$HOME/.cache/puppeteer/chrome-headless-shell/mac_arm-151.0.7922.47/..."

版本號寫死在路徑裡。我這台機器上的 puppeteer 已經升到 152,那個目錄不存在了,腳本就一路改試後面的備用選項,最後用到完整版的 Chrome。完整版起不來也不會結束,腳本每隔 30 秒逾時一次,拖到 500 秒才失敗。

寫死的版本號只是踩到它的那一腳。 真正讓它變成 500 秒的是後面那個備用機制:找不到指定的瀏覽器,它沒說一聲就換下一個繼續跑。你在輸出裡看不到它根本沒用你以為的那個瀏覽器,只看得到 24 個檢查各自失敗。

改成用萬用字元取最新一版,只要兩行(排序要用 sort -V,理由寫在範例的 README 裡)。修完再跑:

05-innerhtml-fake-green   4s   rc=0   7 綠 0 紅

0 綠 24 紅變成 7 綠 0 紅,那一支從 500 秒掉到 4 秒。21 支的合計從 568 秒掉到 90 秒,而那 90 秒裡有 35 秒是另一支要連外的(07 會去 npm registry 查某個套件在不在),所以每次實際跑的時間都不一樣。

所以那 568 秒裡有 500 秒是一支腳本壞掉,不是這套檢查本身慢。 我昨天以為要處理的問題是「跑太久」,量完發現慢的不是這套檢查,是一支腳本綁死了一個會自己升版的東西。至於 90 秒加上排隊時間會不會還是讓人想跳過,那要再跑幾輪才知道。

那分級還要不要做?要,但理由完全不同。

檢查沒過有三種原因,CI 看起來卻都一樣

修完 05 之後那一輪,還有一支是紅的:

06-run-it-somewhere-else   1s   rc=1
[FAIL] 找到 docker 但引擎沒有回應。先把它叫起來,這不是可以跳過的情況

Docker Desktop 沒開。這條紅跟我改的任何一行程式碼都沒關係。

於是同一次執行裡有三種東西都叫做紅:

這次遇到的 是什麼
Docker 引擎沒開 環境沒準備好
Chrome 路徑過期 檢查自己壞了
判準被改壞 真的抓到問題

在我這份分級裡,只有第三種會直接擋合併。前兩種擋住你,第一週就會讓你把整套關掉。

(這個排法不是通則。環境本身就是交付條件的專案,「環境沒準備好」也可能該擋。)

而在 CI 的介面上,這三種只有一個表現形式:那個 job 是紅的。

所以分級得看這支檢查為什麼沒過,不能只看它重不重要。後面還有更麻煩的事。

同一件事,一支算通過,一支算失敗

我把 21 支的收尾行拉出來看,想確認它們對「跳過」的處理一致。不一致,而且有四種寫法。

最糟的是這兩支放在一起看。01 需要網路抓 esbuild,抓不到的時候:

$ESB --version >/dev/null 2>&1 || { echo "抓不到 esbuild(要網路),全部跳過"; exit 0; }

離開碼 0。在 CI 上那是綠燈,而它一項檢查都沒跑。

07 也需要網路,它的收尾是這樣:

[ "${FAIL}" = 0 ] && [ "${SKIP}" = 0 ]

只要有檢查被跳過就不算通過。同樣沒網路,它是紅的。

同一台機器碰到同一個環境問題,一支算通過,另一支卻算失敗。

這時候排出來的分級表講不通。表格原本要告訴你「這一級的檢查失敗時該怎麼辦」,而現在同一個失敗狀態可能代表四件不同的事。

所以第一件事是先講好離開碼各自代表什麼,然後寫進那幾支會跳過檢查的腳本收尾(下面叫它公約):

[ "${FAIL}" != 0 ] && exit 1   # 紅,這是你要它擋你的那種
[ "${SKIP}" != 0 ] && exit 2   # 環境不到位,或有幾項檢查被跳過,沒有結論
exit 0                          # 綠,而且真的驗過了

八支要改。剩下十三支沒有「跳過」這個狀態,本來就只會是 0 或 1。

有件事要先講清楚,免得你以為 GitHub 認得這個 2:它不認得。 Actions 那邊只要離開碼不是 0 就是失敗,exit 2 在介面上跟 exit 1 長得一樣紅。這個 2 是我自己腳本裡的語意,給讀那個輸出的人看的。它之所以不擋合併,是因為會回 2 的那幾支被我放在只警告那一級、沒有列進必要檢查,不是因為 GitHub 幫我分了第三種燈號。

改完拿 01 驗一次,用一個假的 npx 模擬抓不到 esbuild:

改之前   離開碼 0(綠燈,什麼都沒驗)
改之後   離開碼 2(沒有結論)

這裡有個副作用我沒預期到。06 那條「找到 docker 但引擎沒有回應」的旁邊,原本寫著一行註解:

# 這不是「這台機器不適用」,是「你要驗的東西壞了」,所以報紅不報跳過

寫的時候我只有兩個選項。報綠是假綠,所以只能報紅。有了第三個選項之後,這個妥協就不必了:公約定義的是「這一跑有沒有結論」,不是「該不該有人去修」。至於「先把它叫起來」,訊息裡講就好。

推上去,本機過的東西掛了五支

公約寫進腳本,workflow 也寫好了。我把它推上去,第一次跑的結果是:

綠 16 / 紅 5

(job 數跟檢查數對不起來,因為有幾支併在同一個 job、21 那支拆成兩個、兩支排定時不綁 push。)

這些檢查在我本機除了 06(Docker 沒開)以外都是綠的。那五個紅裡,有四個是我本機連跑四輪都看不到的。

五支各有不同的原因,而 CI 介面只給你五個一模一樣的紅叉。

紅的 根因
20 tr '\n' '、':macOS 的 tr 換成完整三個位元組,Linux 的只換第一個
17 用了 md5,這個名字多數 Linux 沒有
16 grep -E\t,macOS 當成 tab、Linux 當成字面的 t
08 要一個「不在 /tmp 底下」的路徑卻用 mktemp -d,Linux 給的就在 /tmp
06 runner 的家目錄沒有它要讀的那五個檔案

前四條是同一類:同一段程式碼,兩個平台不同意思。我在同一台 macOS 上連跑四輪都綠,那只證明它在那個環境可以重複。第五條是環境。

這次明明是檢查壞了,錯的卻是別人

17 那則特別值得看:

verify.sh: line 264: md5: command not found
  [FAIL] 改了 deleted 但表沒動

指令不存在,兩個變數一起變成空字串,[ "" != "" ] 判為假,於是它報告「改了 deleted 但表沒動」。

這句話指控的是 summarise.mjs 有 bug,而那支程式完全正常。

我第一個反應是回去翻 Day 17 那篇的結論對不對,不是去看 md5 在不在。這比「跑太久」更快讓人把 CI 關掉,因為它每次都在說你昨天做的東西是錯的。

(同樣的錯我今天撞到兩次,另一次是平行執行造成的,記在 recipe 22 的 README。)

我以為我把兩種紅分開了

修完那四支跨平台的問題,A 級全綠。接下來做規格要求的最後一步:故意弄壞一條會擋合併的測試,確認它真的擋得住。

這裡有個東西 workflow 自己給不了。要真的擋住合併,得去 GitHub 的分支保護把那些檢查設成必要檢查。我那兩次 PR 是這樣的:還沒設的那次合併狀態是 UNSTABLE,檢查紅歸紅,按下去還是合得掉;設了之後那次才是 BLOCKED。(那個狀態還會被別的保護規則影響,不是只有必要檢查一個變因。)

(那個設定要 repo 的管理權限。公司的 repo 你可能點不了,那就先把 workflow 接上去:紅燈照樣會出現在每個 PR 上,只是擋不住合併。今天講的分級跟公約在那個狀態下一樣成立,差別只在最後那一哩。)

我把「騙」加回黑名單,讓 Day 20 那條誤擋回來,開一個 PR。

預期是這樣的。昨天那三條測試裡,B4d1 守的是防線,b1 釘的是一個已知缺口。兩種紅的正確處置相反:

  • 防線紅了,「改期望值」等於把防線關掉
  • 已知缺口那條(昨天叫它缺口樁)紅了,「改期望值」就是正確處置,因為那個取捨要重新做一次

所以我在 workflow 上開了兩個 job,一個叫「21 的防線」進必要檢查,一個叫「21 的缺口樁」不進。

PR 跑完,兩個都紅。

因為兩個 job 都跑整支 verify.sh,而它第一條就跑 regress.mjs 三條全部。b1 的紅照樣算進防線那個 job。

分在 CI 那一層是假的分。 我只是把同一個離開碼顯示成兩個名字。

要把兩類結果分開,就得從產生結果的地方下手。我第一版替 regress.mjs 的每一筆加上「類」欄,再用離開碼區分:防線失敗回 1,只有缺口樁變動回 2。

看起來修好了。而它還是壞的,我是把稿子交出去審才知道的。

同一個錯誤,我犯了第二次

兩個問題。

第一個:離開碼是互斥的,一次只能講一件事,而那兩類可以同時紅。 「騙」加回黑名單的時候 B4b1 都紅了,regress.mjs 先看防線回 1,而缺口樁那個 job 只認得 2,於是它走到最後一行印出:

缺口樁 b1、B5 都還釘在原地(綠 = 狀態沒變)

那時 b1 明明是紅的。那個 job 綠掉了,而且它印的那句話是錯的。

第二個更難看:我在定完公約的同一天,就自己借用了 2。 公約說 2 是「這一跑沒有結論」,而我拿它表示「缺口樁動了」。同一個數字指兩件事,處置還相反:前者要人去把環境補起來,後者要人重新做一次取捨。

這篇要證明的就是「先定義紅代表什麼」,而我在示範裡犯了同一個病。

最後我沒有再加第三個碼。離開碼只回答「這次有沒有檢查失敗」,兩個 job 各跑一類,分支保護再決定哪一類會擋住合併

node regress.mjs --only 防線      # 只看 B4、d1,這個 job 進必要檢查
node regress.mjs --only 缺口樁    # 只看 b1,這個不進

改完重跑那三種情境:

情境 B4 d1 b1 防線 job 缺口樁 job
現況
「騙」加回黑名單(這是退步)
補一條組合判斷把缺口補起來(這是改善)

第三列是重點:b1 一樣是紅的,而防線那個 job 是綠的,所以那個改善合得進去。

重開那個 PR,兩個 job 各自報自己那一類:

A 21 的防線     1 綠 1 紅(防線 1、缺口樁 0)
C 21 的缺口樁   0 綠 1 紅(防線 0、缺口樁 1)

讓模型看這次改動,但不讓它擋你

還有一件今天該做的:把每次的改動丟給模型,要它列「這次可能引入的疏漏候選」。

我本來想接進 CI,查完放棄了。官方那個 actions/ai-inference 的說明第一句是「The action is Copilot-only」,驗證通常靠 COPILOT_GITHUB_TOKEN,要 Copilot 額度跟組織政策配合(2026-08-22 查證)。換別家的模型 API 也一樣要處理憑證跟額度。依賴 Copilot 額度跟組織政策的東西,不該是這個系列的必要步驟。

所以我改成在本機跑。然後撞到一件更麻煩的事,而它才是這一節真正要講的。

你要餵給模型的那份 diff 是不受信任的資料。這件事我們在 Day 10 講過(指令跟資料走同一條通道)、Day 11 講過(模型讀到的網頁)、Day 12 也講過(工具描述)。現在輪到你自己的程式碼審查。

別人送來的 PR 可以在 diff 裡藏一段話,內容是講給模型聽的。而你手邊那個 CLI 多半是帶工具權限的:讀得到檔案、跑得了指令、載得進 MCP。那段話就有機會讓它去讀別的東西。

所以我去關那些權限。造一個只有我知道內容的檔案,先試了三個看起來相關的:

--disallowed-tools Read Bash Glob Grep Edit Write WebFetch WebSearch
--permission-mode plan
--allowed-tools NoSuchTool

三個都沒擋住,它照樣把那個檔案的內容印出來。而那不代表關不掉,只代表我挑錯旗標了。同一支 CLI 的說明裡有 --tools,換成它就擋住了:

$ claude --print --tools "" --no-session-persistence
**Read** `/tmp/canary.txt`      ← 它試了,但拿不到內容

claude 2.1.238,2026-08-22 實測,同一個問題不加參數會直接印出檔案內容。)

我把這段留在文章裡,是因為我一開始的結論是錯的。 我測了三個旗標、都沒擋住,然後差點寫成「這個 CLI 關不掉工具」。那是「我沒找到」跟「它做不到」的差別,而我在 Day 12 才剛講過同一件事。

那支腳本預設仍然不幫你呼叫任何東西,只把 prompt 加 diff 印出來讓你自己貼。要貼進哪裡有個前提:那個對話本身也不能有檔案存取、連接器、瀏覽器控制或 shell。純文字的對話視窗沒有這個問題;帶著工具的那種,風險原封不動。

我拿今天自己的改動貼進去。它列了八條,第一條是這個:

verify.sh 還在消費舊的離開碼語意,這次 diff 沒改到它。
只有 b1 紅的時候 regress.mjs 回 1,會掉進 verify.sh 的 *) 分支,
報成「防線紅了」,而且它抓 B4/d1 紅行的 grep 會是空的,訊息空白。

我去跑了一次,真的:

[FAIL] 防線紅了:

冒號後面是空的。它指控防線塌了,而防線好好的。 那是我一個小時前改出來的,就在我寫完「兩種紅要分開」那一段之後。

這一步不能擋合併,理由跟前面那條公約同源:模型提的東西沒有經過任何驗證,它八條裡有兩條標了「不確定」,還有一條我看完判斷不成立。模型只負責列出可能的疏漏,判斷還是你自己來。 讓它擋合併,你會在三天內把它關掉,然後連那一條真的有用的也一起失去。

最小的那一份,十行

你今天要接的話,從這個開始就夠。一個 recipe 一個 job,因為每個 job 是獨立的 checkout,那些會互相踩的腳本自然分開:

name: 檢查
on: [pull_request]
jobs:
  blocking:
    strategy:
      fail-fast: false
      matrix:
        recipe: [你要擋合併的那幾支]
    runs-on: ubuntu-24.04
    steps:
      - uses: actions/checkout@v4
      - run: |
          cd "recipes/${{ matrix.recipe }}"
          rc=0
          bash verify.sh || rc=$?     # Actions 的 shell 是 bash -e,
          exit "${rc}"                # 直接讀 $? 讀不到,非零會當場中止

只警告的那幾支複製一份改個 job 名字,然後不要把它們加進分支保護的必要檢查清單。不用 continue-on-error,那個旗標會把紅燈顯示成綠的。

三層決定,各管一件事

一支檢查跑完之後的三層決定

這張圖要看的是那三個菱形:離開碼只回答「這一跑有沒有東西紅」,哪一類紅了由哪個 job 在跑決定,擋不擋合併由分支保護決定。 我今天犯的兩次錯,都是想讓其中一層兼差做下一層的事。

這一段是今天真正的判斷

我一開始接 CI 的做法,是把測試指令貼進 workflow,再決定哪幾個列為必要檢查。做完才發現那只動到了門面。

一個 job 的名字不會改變它的回傳值在說什麼。 你在 CI 介面上把兩種紅擺成兩排,它們回的還是同一個非零、顯示成同一種失敗。

第二次踩的是更細的那一層:回傳值分了,但它是互斥的,所以兩件事同時發生的時候你還是只聽得到一件。兩次都是把「看起來不一樣」當成了「真的分開了」。

而人會照著自己收到的通知養成習慣。b1 沒過的時候,本來就該去改期望值;但它如果長期跟 B4 走同一條通知路徑,久了你看到失敗就會先改期望值,連 B4 這種防線塌掉也照做。

那比按跳過更難察覺,因為每一步看起來都有人在維護測試。

所以順序是反過來的:先讓每一支檢查說得清楚「我這個紅代表什麼」,分級才有東西可分。分級表的每一格,其實都是在回答「這一級收到 1 怎麼辦、收到 2 怎麼辦」。

這件事在 AI 應用上比在一般專案上更要緊。昨天那條誤擋,是我改判準的時候自己咬到的,改的是一個判斷用的詞,不是一段邏輯。這種判準每動一次,都可能讓某條原本擋得住的東西過去,而被擋掉的正常使用者不會來跟你講,攻擊者更不會。沒有針對這些案例的自動回歸,你就少了一條在合併前發現退步的路徑。

今天你的專案裡多了一份分級表

levels.tsv 是那張表:要擋合併的 16 支、只警告 3 支、定時跑 2 支,加上 21 裡面那條缺口樁。workflow 沒有讀它,兩邊都是手寫的,另有一支閘負責比對它們沒有分岔。

那支閘我讓它紅過一次才收工。但它只認得我想得到的那幾種寫法,換一種就掃不到:

command -v 某個工具 >/dev/null || { echo "少了東西,這次不跑"; exit 0; }

一項檢查都沒跑,離開碼是 0,那支閘還是判它通過。昨天那條形狀斷言也有同樣的限制,只是這次輪到閘漏掉它。

昨天還留了一個決定給今天:那個新長出來的誤擋(昨天叫它 B5)算不算擋合併。答案是不算,理由跟我昨天想的不一樣。我以為要在表上給它一格,查下去發現它根本不在 14 那支的判準路徑上,14 跑的是罐頭模型,不經過那道會誤擋它的閘。真正釘著它的是 20 那支的第 6 條,拿那句話算指紋比對紀錄。同一個已知失敗在不同檢查裡走不同路徑,所以「算不算擋合併」取決於誰在跑它。

兩個計數器今天不動,攻擊集 21 條、應放行集 5 條。長的是別的東西:那些檢查現在會自己跑,而且紅的時候說得出自己是哪一種紅。

最後還有兩筆帳沒付。06 在 CI 上每次都失敗,因為 runner 沒有它要找的那五個檔案。這種 job 久了大家根本不會點,哪天換成別的原因失敗也一樣看不到:每次都失敗不是一種分級,是一筆帳。 還有那四個會寫別人檔案的地方也沒修,CI 上一個 job 一份 checkout 把它蓋住了,你自己開兩個終端機同時跑照樣撞得到。

明天要問的是你沒想到的那些

今天這套東西守的是你已經知道的洞:Day 20 抓到的誤擋、Day 19 的注入、昨天那三條。我演練過的那幾種退步,它都抓得到。

但它答不出另一個問題:還有哪些入口,是我從第一天到現在都沒想到的?

明天開始 Part IV。第一件事是把入口清點出來,所有外面的東西進得來的地方:公開的端點、上傳、webhook、模型會讀到的網頁、還有那個不長得像介面的知識庫寫入口。清點的方式有兩個方向,其中一個會列出一堆實際上走不通的死路徑。照單全收的話,你會花一整天打空氣。


上一篇:Day 21|黑名單拿掉一個字,測試通過,誤擋還在

今天這一份:recipe 22|範例專案:github.com/cyh7789/ai-security


上一篇
Day 21|黑名單拿掉一個字,測試通過,誤擋還在
系列文
你該防的不是駭客,是你自己的 AI:在本機驗證你的防線22
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言